Linux多线程服务端编程 读书笔记1

第一部分 C++多线程系统编程

析构所在的线程

当share_ptr引用计数归零时, 会在归零的这个线程就地析构, 而这个线程不一定是对象诞生的线程.

这就引发出一个问题 :

  • 如果对象的析构比较耗时, 且经常在关键线程触发析构, 就会拖慢关键线程的速度.

这种情况的解决方案是专门做一个阻塞队列存入要析构的智能指针, 另开一个专门析构的线程从队列中取出进行析构.

多线程适用场景

  • 其核心作用在于提高响应速度, 不是提高绝对性能, 而是提高平均响应性能.

  • 绝对的IO密集和绝对的计算密集都并非多线程的优势, 前者性能瓶颈在磁盘/网络IO(简单说就是从磁盘/网络缓冲区读数据太慢了, 读到的内容cpu可以马上处理, 而且单线程就可以完成, 根本不是cpu的问题), 后者多进程也完全可以.

  • 适用多线程的条件 :

    • 存在数据共享.
    • 提供非均质的服务, 有优先级差异或难易度差异.
    • 希望使用异步操作.
    • 需要有效划分责任和功能.
  • 多线程可以让IO和计算相互重叠, 降低延迟, 简单讲就是如果用一个主线程专门用来响应, 其将工作分发给工作线程, 相比于只用一个主线程响应+工作, 一个任务速度一样, 但是多个任务并发效率就会明显提升.

  • 一个事件循环的多线程程序应该轻松支持5万并发连接.

  • 多线程可以有效降低”简单任务被复杂任务被任务压住”的出现概率.

  • BlockingQueue是解决多线程程序的利器.

  • 虽然所有网络写操作都可以异步去做, 但是如果Tcp缓存区是空的, 也可以直接在服务线程写完, 这是一种网络库的性能优化策略.

线程模型选择

  • 计算密集型 : Reactor + 线程池(多线程) -> IO不是瓶颈, 多线程增加cpu利用, 比多进程易于管理.
  • 轻量级请求(短连接 + 快速响应) : Reactor + 单线程 -> 处理本身迅速, 无需线程池, 参考Nginx.
  • IO密集型 : Proactor -> 事件IO全部交给内核处理, 自己线程运行响应不受影响.
  • 延迟敏感/要求高吞吐 : Proactor -> 可以随时同时处理很多任务(虽然是内核背后实现的).

多线程磁盘IO

如果想提高文件读取效率, 可以使用多线程每个线程分配多个部分同时读取, 都使用pread, 拼接到目标的buffer.

也可以使用mmap, 从映射中每个线程分别读取自己的部分到buffer中.

当然如果想要磁盘IO到socket, sendfile这种内核函数往往才是最优解.

现在也可以使用io_uring, 其可以实现异步将数据从磁盘搬到用户缓冲区, 真异步, 并且搬运效率还比前两种高.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <liburing.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main() {
io_uring ring;
io_uring_queue_init(8, &ring, 0);

int fd = open("file.txt", O_RDONLY);
char* buf = (char*)malloc(4096);

struct io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_submit(&ring);

struct io_uring_cqe* cqe;
io_uring_wait_cqe(&ring, &cqe);

if (cqe->res < 0)
fprintf(stderr, "read error: %s\n", strerror(-cqe->res));
else
write(STDOUT_FILENO, buf, cqe->res);

io_uring_cqe_seen(&ring, cqe);
close(fd);
free(buf);
io_uring_queue_exit(&ring);
}

Linux分配文件描述符方式所引发的问题

  • Linux分配文件描述符, 分配的是当前最小可用的文件描述符码.

这种分配方式有可能会引发”串话”, 简单说就是两个不同时间的对象持有同一个文件描述符, 向前一个发送的消息有可能发送到后一个中. 解决方法是 :

  • 用RAII包装文件描述符.

人话说就是用一个类管理文件描述符, 这个类在外部用智能指针之类的资源管理类维护, 这样就算文件描述符一样, 但是我们看的是持有这个文件描述符的对象, 只要各自有一个对象就行.

实践到Muduo库中, 就是TcpConnection, 一般用share_ptr<Tcpconnection>, 只要连接断开这个对象就会析构, 但是我们处理事务对象也会同时拥有对应的weak_ptr<Tcpconnection>用来观察, 在事务处理完后就可用通过提升弱指针观察Tcpconnection是否还存在, 存在就发送, 不存在就算了, 这种处理技术又被称为 — 弱回调.

细碎知识

  • 最低限度地共享对象, 优先使用高层同步设施(线程池, 队列), 如果不得已必须, 优先使用互斥锁和条件变量, 不要用读写锁和信号量.

  • 读写锁的效率不一定比互斥锁高, 因为其会维护额外的东西, 并且我们一般希望临界区短些, 临界区越短, 读写锁的优势越小.

  • 必须通过循环判断布尔值来解决虚假唤醒问题.

  • 单例模式中的实例一般是静态变量, 在第一次执行时创建, C++11后保证静态变量的创建是线程安全的, 也就是说这种创建方式是线程安全的. 也可以通过new在堆上创建, 这种创建是可以传入参数的(静态变量不可), 并且销毁是可控的, 但是这里会有线程安全问题, 可以通过call_once来解决 :

    1
    2
    3
    4
    5
    6
    7
    template <typename... Args>
    static T* instance(Args&&... args) {
    std::call_once(flag_, [&] {
    instance_ = new T(std::forward<Args>(args)...);
    });
    return instance_;
    }traverse

    其保证内部的代码原子且只被执行一次.

  • one loop per thread 的优势 :

    • 线程数目固定, 开始时创建, 不会频繁地创建和销毁, 如果用一个线程池去控制也更方便, 更好控制.
    • 负责调配方便, 数目固定直接轮询其实就是非常好的方案.
    • 每个连接会对应固定线程, 不需要关心线程安全, 没有锁的顾虑.
  • 进程间通信选择TCP往往是最优的选择, 因为其天然跨主机, 可以长连接, 更稳定, 相比于多出来的一些花销, 其使用更加简洁且功能丰富.

  • 当想限制CPU的占用率时, 需要使用单线程.

  • 系统的文件函数一般都是线程安全的, 但是如果组合起来的话就可能有问题, 比如想指定读某个文件的某部分, 就需要先lseek再read, 但是lseek和read的中间可能有其他线程也会调用lseek, 由于lseek的影响是全局的, 就会read错误. 解决方法是使用pread, 其就是内部封装了lseek和read再使其原子化.

  • 多线程程序中不要使用信号, 就算要用也要转换成统一的事件, 推荐用signalfd之间转化为文件描述符事件.


Linux多线程服务端编程 读书笔记1
http://example.com/2025/07/12/[Linux多线程服务端编程] 读书笔记(1)/
作者
天目中云
发布于
2025年7月12日
许可协议